<?php  if (!defined('BASEPATH')) exit('No direct script access allowed');
/**
 *  This file is part of CI-ActiveRecord
 *
 *  CI-ActiveRecord is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  CI-ActiveRecord is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with CI-ActiveRecord.  If not, see <http://www.gnu.org/licenses/>.
 */
 
require BASEPATH . '/libraries/Model.php';

require 'Activerecord/Exception.php';
require 'Activerecord/Inflector.php';
require 'Activerecord/Lib.php';
require 'Activerecord/Event.php';
require 'Activerecord/Validator/Interface.php';
require 'Activerecord/Validator/ErrorStack.php';
require 'Activerecord/Validator/Exception.php';
require 'Activerecord/Behaviour.php';
require 'Activerecord/Listener.php';

// Define some global types of search

if (!defined('ALL')) define('ALL', 'all');
if (!defined('IS_NULL')) define('IS_NULL', ' is null');
if (!defined('NOT_NULL')) define('NOT_NULL', ' <> ""');

/**
 * ActiveRecord
 * 
 * Codeigniter ActiveRecord Class
 * 
 * This is based on work done by Matthew Pennell
 * <http://codeigniter.com/wiki/ActiveRecord_Class>
 *
 * @package		Codeigniter
 * @subpackage	Libraries
 * @category	Libraries
 * @author		Richard Standbrook
 * @version		0.0.1
 */
class ActiveRecord extends Model {

    /**
     * VERSION
     */
    const VERSION           = '0.0.1';

    /**
     * ERROR CONSTANTS
     */
    const ERR                       = -1;
    const ERR_UNDEFINED_METHOD      = -2;
    const ERR_UNDEFINED_PROPERTY    = -3;
    const ERR_INVALID_DATA          = -4;
    const ERR_RECORD_IS_TRANSIENT   = -5;
    const ERR_UNKOWN_BEHAVIOUR      = -6;
    const ERR_INVALID_BEHAVIOUR     = -7;
    const ERR_UNKOWN_VALIDATOR      = -8;
    const ERR_INVALID_VALIDAOR      = -9;
    const ERR_CANNOT_SAVE           = -10;
    const ERR_CANNOT_UPDATE         = -11;
    const ERR_CANNOT_DELETE         = -12;
    const ERR_CANNOT_DELETEALL      = -13;
    const ERR_UNKOWN_STATE          = -14;
    

    /**
     * STATE CONSTANTS
     */

    /**
     * PROXY STATE
     * an ActiveRecord is in proxy state when its properties are not fully loaded
     */
    const STATE_PROXY       = 1;

    /**
     * DIRTY STATE
     * an ActiveRecord is in dirty state when its properties are changed
     */
    const STATE_DIRTY       = 2;

    /**
     * TDIRTY STATE
     * an ActiveRecord is in transient dirty state when it is created and some
     * of its fields are modified but it is NOT yet persisted into database
     */
    const STATE_TDIRTY      = 3;

    /**
     * CLEAN STATE
     * an ActiveRecord is in clean state when all of its properties are loaded 
     * from the database and none of its properties are changed
     */
    const STATE_CLEAN       = 4;

    /**
     * NEW TCLEAN
     * an ActiveRecord is in transient clean state when it is created and none 
     * of its fields are modified
     */
    const STATE_TCLEAN      = 5;

    /**
     * LOCKED STATE
     * an ActiveRecord is temporarily locked during deletes and saves
     *
     * This state is used internally to ensure that circular deletes
     * and saves will not cause infinite loops
     */
    const STATE_LOCKED     = 6;
    
    /**
     * Private properties, storing information about the class and database table
     * 
     * @access private
     * 
     * @var string  $_className     The name of the ActiveRecord extended class
     * @var string  $_tableName     The name of the table associated with the class
     * @var array   $_columns       A multi-dimentional array of column meta
     * @var object  $_data          Object containing properties for each field in the table
     * @var array   $_validators    An array of validators for each field
     * @var array   $_behaviours    An array of behaviours assigned to this object
     * @var array   $_listeners     An array of listeners attached to this object
     */
    private $_className;
    private $_tableName;
    private $_columns;
    private $_data;
    private $_validators = array();
    private $_behaviours = array();
    private $_listeners = array();
    
    /**
     * @var integer $_state         The state of this record
     * @see STATE_* constants
     * @access protected
     */
    protected $_state;

    /**
     * @var array   $_modifiedFields    An array containing field names that have been modified
     * @access protected
     */
    protected $_modifiedFields     = array();
    
    /**
     * @var ActiveRecord_Validator_ErrorStack   Error stack object
     * @access protected
     */
    protected $_errorStack;
    
	/**
	 * __construct
	 * ActiveRecord constructor
	 *
	 * @access public
	 */
    public function __construct()
    {
    	parent::__construct();
        log_message('debug', "ActiveRecord Class Initialized");
        
        if (get_class($this) != __CLASS__) {
            $this->_className = get_class($this);
            $this->tableName($this->_className);
        }
        
        $this->state(self::STATE_PROXY);
        $this->getErrorStack();
    }
    
    /**
     * tableName
     * 
     * Get or set the name of the database table
     * 
     * @access  public
     * @param   string
     * @return  string
     */
    public function tableName($name=null)
    {
        if ($name !== null) {
            $this->_tableName = (string) Inflector::tableize($name);
        }

        return $this->_tableName;
    }
    
    /**
     * addListener
     * 
     * Register an ActiveRecord_Listener Object
     * 
     * @access  public
     * @param   ActiveRecord_Listener   $listener
     */
    public function addListener(ActiveRecord_Listener $listener)
    {
        $this->_listeners[] = $listener;
    }
    
    /**
     * triggerEvent
     * 
     * Call the event on each registered listener.
     * 
     * @access  public
     * @param   ActiveRecord_Event  $event
     * @param   array               $args   (optional) Additional arguments to pass to the Listeners
     */
    public function triggerEvent(ActiveRecord_Event $event, $args=array())
    {    
        if ( ! is_array($args)) {
            $args = array($args);
        }

        foreach ($this->_listeners as $listener) {
            array_unshift($args, $event);
            call_user_func_array(array($listener, $event->getName()), $args);
        }
    }
    
	/**
	 * discoverTableColumns
	 *
	 * Called on instantiation of a model to capture the field names
	 * of the related table.
	 *
	 * @access	public
	 */	
    public function discoverTableColumns()
    {
    	$this->_columns = $this->db->query('SHOW COLUMNS FROM ' . $this->_tableName)->result();
    	
    	foreach ($this->_columns as $column) {
    	    $this->_data->{$column->Field} = null;
    	}
    }
    
    /**
     * discoverValidation
     * 
     * Automatically build validation based on the type and null value of each column
     * 
     * @access  public
     */
    public function discoverValidation()
    {
        if ( ! $this->_columns) {
            throw new ActiveRecord_Exception('Can not discover validation without column data');
        }
        
        foreach ($this->_columns as $column) {
            if ($column->Null == 'NO') {
                $this->addValidation($column->Field, 'Notnull');
            }
        }
    }
    
    /**
     * addValidation
     * 
     * Add an ActiveRecord_Validator to the validation stack
     * 
     * @access  public
     * @param   string  $field
     * @param   string  $validator
     */
    public function addValidation($field, $validator)
    {
        $validator = ucfirst(strtolower($validator));
        log_message('debug', "Apply $validator Validator to $field");
        
        if ( ! array_key_exists($field, $this->_validators)) {
            $this->_validators[$field] = array();
        }
        
        if (array_key_exists($validator, $this->_validators[$field])) {
            log_message('debug', "$validator Validator already applied");
            return TRUE;
        }
        
        $file_name = APPPATH . 'libraries/Activerecord/Validator/' . $validator . '.php';
        $class_name = 'ActiveRecord_Validator_' . $validator;
        
        if (file_exists($file_name)) {
            require_once $file_name;
            
            if (class_exists($class_name)) {
                $v = new $class_name($this);
                log_message('debug', "$validator Validator applied");
            }

            if ($v instanceof ActiveRecord_Validator_Interface) {
                $this->_validators[$field][$validator] = $v;
                return TRUE;
            }
            
            throw new ActiveRecord_Exception(self::ERR_INVALID_VALIDAOR);
        }
        
        throw new ActiveRecord_Exception(self::ERR_UNKOWN_VALIDATOR);
    }
    
    /**
     * validate
     * 
     * Validate this object
     * 
     * @access  public
     * @return  boolean
     */
    public function validate()
    {
        $data = $this->_data;
        unset($data->id);
        
        foreach ($data as $key => $value) {
            if (array_key_exists($key, $this->_validators)) {
                foreach ($this->_validators[$key] as $validator) {
                    if ( ! $validator->validate($value)) {
                        $this->_errorStack->add($key, $validator);
                    }
                }
            }
        }

        if ($this->_errorStack->hasErrors()) {
            throw new ActiveRecord_Validator_Exception(self::ERR_INVALID_DATA);
        }
        
        return TRUE;
    }
    
    /**
     * hasProperty
     * 
     * Check if the object has a given property
     * 
     * @access  public
     * @param   string
     * @return  boolean
     */
    public function hasProperty($prop_name)
    {
        return array_key_exists($prop_name, $this->_data);
    }
    
    /**
     * actAs
     * 
     * Adds a behaviour for this object
     * 
     * @access  public
     * @param   string
     */
    public function actAs($behaviour)
    {
        $behaviour = ucfirst(strtolower($behaviour));
        log_message('debug', "Load $behaviour Behaviour");
        
        if (array_key_exists($behaviour, $this->_behaviours)) {
            log_message('debug', "$behaviour Behaviour already loaded");
            return TRUE;
        }
        
        $file_name = APPPATH . 'libraries/Activerecord/Behaviour/' . $behaviour . '.php';
        $class_name = 'ActiveRecord_Behaviour_' . $behaviour;
        
        if (file_exists($file_name)) {
            require_once $file_name;
            if (class_exists($class_name)) {
                $this->_behaviours[$behaviour] = new $class_name($this);
                log_message('debug', "$behaviour Behaviour loaded");
                return TRUE;
            }
            
            throw new ActiveRecord_Exception(self::ERR_INVALID_BEHAVIOUR);
        }
        
        throw new ActiveRecord_Exception(self::ERR_UNKOWN_BEHAVIOUR);
    }

    /**
     * getErrorStack
     *
     * @return Doctrine_Validator_ErrorStack    returns the errorStack associated with this record
     */
    public function getErrorStack()
    {
        if ( ! $this->_errorStack) {
            $this->_errorStack = new ActiveRecord_Validator_ErrorStack(get_class($this));
        }
        
        return $this->_errorStack;
    }
    
    /**
     * state
     * Returns / assigns the state of this record
     * 
     * @param   integer $state            If set, this method tries to set the state
     * @see Activerecord::STATE_* constants
     *
     * @throws  ActiveRecord_Exception   If trying to set an unknown state
     * @return  integer
     */
    public function state($state = null)
    {
        if ($state === null) {
            return $this->_state;
        }
        
        if (is_integer($state)) {
            if ($state >= 1 && $state <= 6) {
                return $this->_state = $state;
            }
        }
        
        throw new ActiveRecord_Exception(self::ERR_UNKOWN_STATE);
    }

	/**
	 * __call
	 *
	 * Catch-all function to capture method requests for Active Record-style
	 * functions and pass them off to private methods.
	 *
	 * @access	public
	 * @param	string
	 * @param	array
	 * @return	mixed
	 * @link	http://uk.php.net/manual/en/language.oop5.overloading.php
	 */	
    public function __call($method, $args)
    {
        $func_map = array('findBy'       => 'findBy',
                          'findAllBy'    => 'findAllBy',
                          'fetchRelated' => 'fetchRelated');
        
        foreach ($func_map as $method_prefix => $callback) {
            if (stristr($method, $method_prefix)) {
                $tmp = str_replace($method_prefix, '', $method);
                $column = Inflector::underscore($tmp);
                return $this->$callback($column, $args);
            }
        }
        
        throw new ActiveRecord_Exception(self::ERR_UNDEFINED_METHOD);
    }
    
    /**
     * __get
     * 
     * Return the value of the requested column, if it exists in the table
     */
    public function __get($key)
    {
        if (property_exists($this->_data, $key)) {
            return $this->_data->{$key};
        }

        throw new ActiveRecord_Exception(self::ERR_UNDEFINED_PROPERTY);
    }
    
    /**
     * __set
     * 
     * Set a property of the object, as long as it exists as a column in the 
     * mapped table. Also adds the column name to $this->_modified
     * 
     * Allows properties beginning with _ci_ to be set
     */
    public function __set($key, $value)
    {
        if (strpos('_ci_', $key) == 0) {
            $this->$key = $value;
            return TRUE;
        }
        
        if (property_exists($this->_data, $key)) {
            $this->_data->$key = $data;
            $this->_modified[] = $key;
            $this->state(self::STATE_DIRTY);
            return TRUE;
        }
        
        throw new ActiveRecord_Exception(self::ERR_UNDEFINED_PROPERTY);
    }
    
	/**
	 * exists
	 *
	 * Boolean check to see if a record was returned from a query
	 *
	 * @access	public
	 * @return	boolean
	 */	
    function exists()
    {
        return (isset($this->_data->id) AND ! empty($this->_data->id));
    }
        
	/**
	 * delete
	 *
	 * Simple method to delete the current object's record from the database.
	 *
	 * @access	public
	 */	
    function delete()
    {
        $event = new ActiveRecord_Event($this, ActiveRecord_Event::PRE_DELETE);
        if ($event->skipOperation) { return; }
        
        if ( ! $this->db->delete($this->_tableName, array('id' => $this->_data->id))) {
            log_message('error', $this->db->last_query());
            throw new ActiveRecord_Exception(self::ERR_CANNOT_DELETE);
        }
        
        unset($this);
        $this->state(self::STATE_TCLEAN);
        $event = new ActiveRecord_Event($this, ActiveRecord_Event::POST_DELETE);
        return TRUE;
    }
    
	/**
	 * deleteAll
	 *
	 * Delete all records from the associated table. This method does not
	 * need to called on an instantiated object.
	 *
	 * @access	public
	 */	
    function deleteAll()
    {
        $event = new ActiveRecord_Event($this, ActiveRecord_Event::PRE_DELETEALL);
        if ($event->skipOperation) { return; }
        
        if ( ! $this->db->query('DELETE FROM ' . $this->_tableName)) {
            log_message('error', $this->db->last_query());
            throw new ActiveRecord_Exception(self::ERR_CANNOT_DELETEALL);
        }

        unset($this);
        $this->state(self::STATE_TCLEAN);
        $event = new ActiveRecord_Event($this, ActiveRecord_Event::POST_DELETEALL);
        return TRUE;
    }
    
	/**
	 * save
	 *
	 * Similar to the create() method, but this function assumes that the
	 * corresponding properties have been set on the current object for each
	 * table column.
	 *
	 * @access	public
	 * @return	boolean
	 */	
    function save()
    {
        $event = new ActiveRecord_Event($this, ActiveRecord_Event::PRE_SAVE);
        if ($event->skipOperation) { return; }

        $this->validate();
        
        $data = $this->_data;
        unset($data->id);
        
        if ( ! $this->db->insert($this->_tableName, $data)) {
            log_message('error', $this->db->last_query());
            throw new ActiveRecord_Exception(self::ERR_CANNOT_SAVE);
        }
        
        $this->_data->id = $this->db->insert_id();
        $event = new ActiveRecord_Event($this, ActiveRecord_Event::POST_SAVE);
        return TRUE;
    }

	/**
	 * update
	 *
	 * Similar to the save() method, except that it will update the row
	 * corresponding to the current object, and only set modified fields.
	 *
	 * @access	public
	 * @return	void
	 */	
	function update()
	{
        $event = new ActiveRecord_Event($this, ActiveRecord_Event::PRE_UPDATE);
        if ($event->skipOperation) { return; }
	    
	    if ($this->state() === self::STATE_TCLEAN OR $this->state() === self::STATE_TDIRTY) {
	        throw new ActiveRecord_Exception(self::ERR_RECORD_IS_TRANSIENT);
	    }
	    
        if ( ! $this->validate()) {
            log_message('debug', "{$this->_className} contains invalid data");
            throw new ActiveRecord_Exception(self::ERR_INVALID_DATA);
        }
        
        $data = array();
        foreach ($this->_modifiedFields as $field_name) {
            if($field_name != 'id') {
                $data[$field_name] = $this->_data->$field_name;
            }
        }

		$this->db->where('id', $this->_data->id);
        if ( ! $this->db->update($this->_tableName, $data)) {
    		log_message('error', $this->db->last_query());
            throw new ActiveRecord_Exception(self::ERR_CANNOT_UPDATE);
        }
        
        new ActiveRecord_Event($this, ActiveRecord_Event::POST_UPDATE);
        return TRUE;
	}

	/**
	 * find
	 *
	 * Basic find function. Either pass in a numeric id to find that table row,
	 * or an array of key/values for a more complex search. Note that passing in 
	 * an array of 1 is stupid, as you can use findBy{FieldName}() instead.
	 *
	 * To simply return all records, use the ALL constant: $myobj->find(ALL);
	 *
	 * @access	public
	 * @param	int || array
	 * @return	object || array
	 */	
    function find($args)
    {
        if (is_array($args)) {
            foreach ($args as $key => $value) {
                if (!isset($first_key)) {
                    $first_key = $key;
                    $first_value = $value;
                }
            }
            array_shift($args);
            $data = array($first_value,
                          $args);
            return $this->findAllBy($first_key, $data);
            
        } else {
            
        	if ($args != ALL) {
	            $query = $this->db->where('id', $args)->from($this->_tableName)->get();
	            if ($query->num_rows() > 0) {
	            	$return = new $this->_className();
	                $found = $query->row();
	                foreach($this->_columns as $column) {
	                    $return->{$column->Field} = $found->{$column->Field};
	                }
					return $return;
	            } else {
					return false;
				}
			} else {
				$query = $this->db->from($this->_tableName)->get();
		        foreach ($query->result() as $row) {
		            $x = new $this->_className();
		            foreach ($this->_columns as $column) {
		                $x->{$column->Field} = $row->{$column->Field};
		            }
		            $return[] = $x;
		            $x = null;
		        }
		        return isset($return) ? $return : false;
			}
        }
    }
    
	/**
	 * findBy
	 *
	 * Query by a particular field by passing in a string/int. You can also
	 * pass in an optional hash of additional query modifiers.
	 *
	 * NOTE: This function only ever returns the first record it finds! To
	 * find all matching records, use find_all_by_fieldname();
	 *
	 * @access	public
	 * @param	string
	 * @param	array
	 * @return	object
	 */	
    public function findBy($column, $query)
    {
        $this->db->where($column, $query[0]);
		$this->db->select($this->_tableName . '.*');
        if (isset($query[1])) {
            foreach ($query[1] as $key => $value) {
                $this->db->where($key, $value);
            }
        }
		if (!isset($query[2])) {
			$this->db->from($this->_tableName);
		} else {
			$this->db->from($this->_tableName . ' ' . $query[2]);
		}
        $query = $this->db->get();
		if ($query->num_rows() > 0) {
	        $found = $query->row();
	        $return = new $this->_className();
	        var_dump($return);
	        foreach($this->_columns as $column) {
	            $return->{$column->Field} = $found->{$column->Field};
	        }
	        return $return;
		} else {
			return $this;
		}
    }
    
	/**
	 * findAllBy
	 *
	 * Same as _find_by() except this time it returns all matching records.
	 *
	 * There are some special search terms that you can use for particular searches:
	 * IS_NULL to find null or empty fields
	 * NOT_NULL to find fields that aren't empty or null
	 *
	 * By passing in a second parameter of an array of key/value pairs, you 
	 * can build more complex queries (of course, if it's getting too complex,
	 * consider creating your own function in the actual model class).
	 *
	 * @access	public
	 * @param	string
	 * @param	array
	 * @return	array
	 */	
    public function findAllBy($column, $query)
    {
        $return = array();
		switch ($query[0]) {
			case IS_NULL:
				$this->db->where($column . IS_NULL);
				break;
			case NOT_NULL:
				$this->db->where($column . NOT_NULL);
				break;
			default:
				$this->db->where($column, $query[0]);
		}
		
        if (isset($query[1])) {
            foreach ($query[1] as $key => $value) {
                $this->db->where($key, $value);
            }
        }
		if (!isset($query[2])) {
			$this->db->from($this->_tableName);
		} else {
			$this->db->from($this->_tableName . ' ' . $query[2]);
		}
        $query = $this->db->get();
        foreach ($query->result() as $row) {
            $x = new $this->_className();
            foreach ($this->_columns as $column) {
                $x->{$column->Field} = $row->{$column->Field};
            }
            $return[] = $x;
            $x = null;
        }
        return $return;
    }
	
	/**
	 * findAndLimitBy
	 *
	 * Basic find function but with limiting (useful for pagination).
	 * Pass in the number of records and the start index, and optionally
	 * an array, where the first index of the array is an array of
	 * modifiers for the query, and the second index is a JOIN statement
	 * (assuming one is needed).
	 *
	 * @access	public
	 * @param	int
	 * @param	int
	 * @param	array
	 * @return	array
	 */	
	public function findAndLimitBy($num, $start, $query = array())
	{
		$return = array();
		$this->db->limit($num, $start);
        if (isset($query[0])) {
            foreach ($query[0] as $key => $value) {
                $this->db->where($key, $value);
            }
        }
		if (!isset($query[1])) {
			$this->db->from($this->_tableName);
		} else {
			$this->db->from($this->_tableName . ' ' . $query[1]);
		}
		$query = $this->db->get();
        foreach ($query->result() as $row) {
            $x = new $this->_className();
            foreach ($this->_columns as $column) {
                $x->{$column->Field} = $row->{$column->Field};
            }
            $return[] = $x;
            $x = null;
        }
        return $return;
	}
    
	/**
	 * createRelationship
	 *
	 * Create a relationship (i.e. an entry in the relationship table)
	 * between the current object and another one passed in as the first
	 * argument. Or pass in two objects as an anonymous call.
	 *
	 * @access	public
	 * @param	object
	 * @param	object
	 * @return	void
	 */	
    public function createRelationship($a, $b = '')
    {
    	if ($b == '') {
			$relationship_table = ($this->_tableName < $a->_tableName) ? $this->_tableName . '_' . $a->_tableName : $a->_tableName . '_' . $this->_tableName;
	        $this->db->query('
				INSERT INTO ' . $relationship_table . ' 
					(' . $this->_className . '_id, ' . $a->_className . '_id) 
				VALUES 
					(' . $this->id . ', ' . $a->id . ')
			');
		} else {
			$relationship_table = ($a->_tableName < $b->_tableName) ? $a->_tableName . '_' . $b->_tableName : $b->_tableName . '_' . $a->_tableName;
	        $this->db->query('
				INSERT INTO ' . $relationship_table . ' 
					(' . $a->_className . '_id, ' . $b->_className . '_id) 
				VALUES 
					(' . $a->id . ', ' . $b->id . ')
			');
		}
    }
	
	/**
	 * fetchRelated
	 *
	 * Fetch all related records using the relationship table to establish
	 * relationships. Results are stored as an array of objects in a
	 * property corresponding to the name of the related objects. If the 
	 * singular of the related object isn't logical, pass it in as the
	 * first argument, e.g. $woman->fetch_related_men('man');
	 *
	 * @access	public
	 * @param	string
	 * @param	string
	 * @return	void
	 */	
	public function fetchRelated($plural, $singular)
	{
		$singular = ($singular) ? $singular[0] : substr($plural, 0, -1);
		$relationship_table = ($this->_tableName < $plural) ? $this->_tableName . '_' . $plural : $plural . '_' . $this->_tableName;
		$query = $this->db->query('
			SELECT 
				' . $plural . '.* 
			FROM 
				' . $plural . ' 
			LEFT JOIN 
				' . $relationship_table . ' 
			ON 
				' . $plural . '.id = ' . $singular . '_id 
			LEFT JOIN 
				' . $this->_tableName . ' 
			ON 
				' . $this->_tableName . '.id = ' . $this->_className . '_id 
			WHERE 
				' . $this->_tableName . '.id = ' . $this->id
		);
		$this->$plural = $query->result();	
	}
}